# Mark Wolgemuth 2010-06
# Apache License see http://www.apache.org/licenses/LICENSE-2.0.html

# This module is designed to provide quick easy integration from rails applications
# registered on Google Marketplace to Google Apps account data.
# It is especially reduced and simplified to support only as much as needed.
#
# It does not address the openid component for SSO: this is for backend server interaction.
#
# The basic use is simple:
# # put this file in your RAILS_ROOT/lib dir
# require 'lib/gdata_marketplace'
# # you'll need your consumer key and secret from the marketplace listing
# # for "users" and "groups" only the domain is needed, eg. "example.com"
# client = GDataMarketplace::Client.new consumer_key, consumer_secret, "person@example.com"
# all_clients_users_as_openstruct_list = client.users
# # and so on with groups, etc
# # follow provides the user record for the id passed in to client ("person")
# user_as_openstruct = client.user
# # can re-use the client for others
# user_as_openstruct = client.user "someoneelse"
#
# The library will look for and use Rails resources by default, specifically the logger and memcache.
# I've not tried this lib outside Rails, but it should work although you may need to add some requires.
#
# An attempt has been made to pull steps apart and expose their results,
# so that you can intercept at any stage back to the raw Google response should you want
module GDataMarketplace
  require "oauth/consumer"
  require "oauth/signature"
  require "oauth/signature/rsa/sha1"
  require "oauth/signature/hmac/sha1"
  require "xml"
  require "xmlsimple"
  require "memcache"

  class GDataMarketplaceError < Exception
  end
  class GDataMarketplaceServerError < GDataMarketplaceError
  end
  class GDataMarketplaceResponseError < GDataMarketplaceError
  end
  class GDataMarketplaceDataError < GDataMarketplaceError
  end

  class Client
    attr_accessor :consumer, :requestor, :domain, :requestor_user, :response_format
    attr_accessor :apps_api_feed_base, :www_api_feed_base, :memcache_expires
    cattr_accessor :memcache_server, :memcache

    # as push data is added, I may make the lib smarter about get vs post
    @@consumer_defaults =  {
      :site => "https://www.google.com",
      :scheme => 'header',
      :method => 'get',
      :request_token_path => "/accounts/OAuthGetRequestToken",
      :access_token_path => "/accounts/OAuthGetAccessToken",
      :authorize_path => "/accounts/OAuthAuthorizeToken",
      :signature_method => 'HMAC-SHA1'
    }

    # just in case there are ever separate dev resources
    @@apps_api_feed_base = "https://apps-apis.google.com:443/a/feeds"
    @@www_api_feed_base = "https://www.google.com"

    @@max_redirects = 10

    # uses your memcache instance for rails or attempts localhost
    # override class var to use something else
    @@memcache_server = Rails.configuration.cache_store[1] rescue "127.0.0.1:11211"
    @@memcache = MemCache.new @@memcache_server rescue nil
    # this expiry is primarily intended to reduce thrash inside reqs or redirect series
    # especially for account signup / setup
    @@memcache_expires = 60

    # consumer_opts are where you can override things like :scheme or :method => 'post'
    # set response_format to nil or :net_http for raw response to parse with your
    # own xml tools. this prevents use of the openstruct helper or cache layers
    def initialize consumer_key = nil, consumer_secret = nil, requestor = nil, consumer_opts = { }
      @consumer_args = @@consumer_defaults.merge consumer_opts
      @apps_api_feed_base = @@apps_api_feed_base
      @www_api_feed_base = @@www_api_feed_base
      @memcache_expires = @@memcache_expires
      self.requestor = requestor
      self.consumer =  OAuth::Consumer.new(consumer_key, consumer_secret, @consumer_args)
      self.response_format = :xmlsimple
    end

    # detect domain and requestor user from the passed in id or domain
    # the user is simply used as default user in single user type data requests
    # has no "pull" data privilege implications
    def requestor= requestor_id
      @requestor = requestor_id
      return nil if @requestor.nil?
      @requestor_user, @domain = @requestor.split '@'
      # catch requestor is domain only
      if @domain == nil || @domain == ''
        @domain = @requestor_user
        @requestor_user = nil
      end
      @requestor
    end

    # helper if component is re-assigned domain
    # sets user to nil since we don't assume same user
    def domain= domain_name
      self.requestor = domain_name
      domain
    end

    # helper for new user on domain
    def requestor_user= name
      self.requestor = "#{name}@#{domain}"
      requestor_user
    end

    def response_format= format
      # currently, only xml raw and xml parsed is working?
      format == :json and raise GDataMarketplaceError.new "alt=json repson is not working?"
      @response_format = format
    end

    # this can change if Google changes API versions
    def url_nickname_provision_readonly who = :all
      if (who.is_a? String) # a user by user name minus @domain
        "#{apps_api_feed_base}/#{domain}/nickname/2.0/#{who}#readonly"
      else # all is :all or nil or whateva
        "#{apps_api_feed_base}/#{domain}/nickname/2.0#readonly"
      end
    end

    # this can change if Google changes API versions
    def url_user_nickames_provision_readonly who = nil
      "#{apps_api_feed_base}/#{domain}/nickname/2.0/#readonly?username=#{who}"
    end

    # this can change if Google changes API versions
    def url_user_provision_readonly who = :all
      if (who.is_a? String) # a user by user name minus @domain
        "#{apps_api_feed_base}/#{domain}/user/2.0/#{who}#readonly"
      else # all is :all or nil or whateva
        "#{apps_api_feed_base}/#{domain}/user/2.0#readonly"
      end
    end

    # this can change if Google changes API versions
    def url_group_provision_readonly which = :all
      if (which.is_a? String) # a user by user name minus @domain
        "#{apps_api_feed_base}/group/2.0/#{domain}/#{which}#readonly"
      else # all is :all or nil or whateva
        "#{apps_api_feed_base}/group/2.0/#{domain}#readonly"
      end
    end

    # this can change if Google changes API versions
    def url_group_members_provision_readonly which = nil
      which.nil? and raise GDataMarketplaceDataError.new "Need a group to get member list"
      "#{apps_api_feed_base}/group/2.0/#{domain}/#{which}/member#readonly"
    end

    # this can change if Google changes API versions
    def url_user_calendar_readonly who = nil
      who.nil? and raise GDataMarketplaceDataError.new "Need a user to access calendar"
      "#{@www_api_feed_base}/calendar/feeds/default/allcalendars/full?xoauth_requestor_id=#{who}@#{domain}"
    end

    # params helper for GET reqs
    def add_to_url_query url, args
      return url if args.nil?
      url += "?" unless url.include? "?"
      url += "&" unless url[-1] == "?"
      if args.is_a? Hash
        args.each { |k,v| url.append (url[-1] == "?") ? "#{k}=#{v}" : "&#{k}=#{v}" }
        return url
      else
        return url + args
      end
    end

    # the oauth req builder magic
    def oauth_2leg_signed_request url
      tok = OAuth::AccessToken.new consumer
      # use http for raw http response object, for debugging
      url = if ([nil, :xmlsimple, :xml, :net_http].include? response_format)
              url
            else
              (add_to_url_query url, "alt=#{response_format.to_s}")
            end
      response = nil
      tries = @@max_redirects
      while response == nil && tries > 0
        logger.debug ">>>>>> url: #{url}"
        response = tok.get url
        if response && ([301, 302].include? response.code.to_i)
          tries -= 1
          url = response.response["Location"]
          response = nil
        end
      end
      if response.code.to_i >= 400
        logger.error "Error response from url[#{url}] error[#{response.inspect}]"
        raise GDataMarketplaceServerError.new "#{response.inspect}"
      end
      respond response
    end

    def respond response
      begin
        case response_format
        when :xmlsimple
          respond_xmlsimple response
        when :xml # what
          respond_rexml response
        when :json # alt=json not working?
          respond_json response
        when :net_http # the actual Net::HTTP response object
          response
        else
          response.body
        end
      rescue Exception => e
        raise GDataMarketplaceResponseError.new "Could not generate xml repsonse: #{e.inspect}"
      end
    end

    def respond_xmlsimple http_response
      XmlSimple.xml_in http_response.body
    end

    def respond_rexml http_response
      (REXML::Document.new http_response.body).root
    end

    def respond_json http_response
      JSON.parse http_response.body
    end

    def logger
      if ActiveRecord::Base.logger
        ActiveRecord::Base.logger
      else
        @logger ||= Logger.new(STDOUT)
      end
    end

    def data
      @data_filter ||= ClientDataFilter.new self
    end

    def cache_get cachekey
      return nil if memcache.nil?
      begin
        result = memcache.get cachekey
        result.nil? ? nil : (Marshal.load result)
      rescue Exception => e
        logger.info "Memcache error: #{e.inspect}"
        nil
      end
    end

    def cache_set cachekey, obj
      return nil if memcache.nil?
      begin
        memcache.set cachekey, (Marshal.dump obj), @@memcache_expires
      rescue Exception => e
        logger.info "Memcache error: #{e.inspect}"
      end
    end

    # user the request_* methods for raw access
    # set client.response_format = nil for really raw access
    def request_user_provision who = :all
      self.oauth_2leg_signed_request (url_user_provision_readonly who)
    end

    def request_nickname_provision who = :all
      self.oauth_2leg_signed_request (url_nickname_provision_readonly who)
    end

    def request_user_nicknames_provision who = nil
      self.oauth_2leg_signed_request (url_user_nicknames_provision_readonly who)
    end

    def request_group_provision which = :all
      self.oauth_2leg_signed_request (url_group_provision_readonly which)
    end

    def request_group_members_provision which = nil
      self.oauth_2leg_signed_request (url_group_members_provision_readonly which)
    end

    def request_user_calendar who = nil
      self.oauth_2leg_signed_request (url_user_calendar_readonly who)
    end

    ### convenience factories + cache
    def user id = nil
      data.cache :user, id
    end

    def users
      data.cache :users
    end

    def nickname id = nil
      data.cache :nickname, id
    end

    def nicknames
      data.cache :nicknames
    end

    def user_nicknames id = nil
      data.cache :user_nicknames, id
    end

    def user_calendar id = nil
      data.cache :user_calendar, id
    end

    def group id = nil
      data.cache :group, id
    end

    def groups
      data.cache :groups
    end

    def group_members id = nil
      data.cache :group_members, id
    end

    ### convenience filters
    def email_for gdata_user
      gdata_user.userName + "@" + domain
    end

  end

  class ClientDataFilter
    attr_accessor :client

    def initialize client
      self.client = client
      self.validate
    end

    def validate
      client.nil? and raise GDataMarketplaceError.new "Cannot have nil client"
      client.requestor.nil?  and raise GDataMarketplaceError.new "Must set requestor domain or email/id"
      if client.response_format != :xmlsimple
        raise GDataMarketplaceError.new "Scaffolded results must use default (:xmlsimple) response format"
      end
    end

    def cache_delete qlass, *args
      client.memcache.delete (cache_key qlass, *args)
    end

    def cache_key qlass, *args
      "gdm-#{client.requestor}-#{qlass.to_s}-#{args.join '-'}"
    end

    # key is from factory method name and args
    # which proves to be correct in all cases I could thing off
    # value is serialized openstruct or openstruct array result
    def cache qlass = nil, *args
      qlass.nil? and raise GDataMarketplace.new "cache must be passed data accessor"
      return self.send qlass, *args if client.memcache.nil?
      key = cache_key qlass, *args
      obj = client.cache_get key
      if obj.nil?
        obj = self.send qlass, *args
        client.cache_set key, obj unless obj.nil?
      end
      obj
    end

    # helper for methods that won't work without user id
    def require_user id = nil
      id = client.requestor_user if id.nil?
      id.nil? and raise GDataMarketplaceDataError.new "Requestor user is nil and no user id passed"
      id
    end

    # openstruct factories
    def users
      Users.new (client.request_user_provision)
    end

    def user id = nil
      User.new (client.request_user_provision (require_user id))
    end

    def nicknames
      Nicknames.new (client.request_nickname_provision)
    end

    def nickname id = nil
      Nicknames.new (client.request_nickname_provision id)
    end

    def nicknames_for id = nil
      Nicknames.new (client.request_user_nicknames_provision id)
    end

    def groups
      Groups.new (client.request_group_provision)
    end

    def group id = nil
      id.nil? and raise GDataMarketplaceDataError.new "Group id cannot be nil"
      Group.new (client.request_group_provision id)
    end

    def group_members id = nil
      id.nil? and raise GDataMarketplaceDataError.new "Group id cannot be nil"
      GroupMembers.new (client.request_group_members_provision id)
    end

    def docs
      raise GDataMarketplaceDataError.new "Not Implemented Yet"
    end

    def user_calendar user_id = nil
      (client.request_user_calendar (require_user user_id))
    end

  end

  # Currently all the open structs are built by analyzing the XMLSimple output
  # I've left room to optimize this with other parsing methods, with possible different
  # intermediate states (the state between text blob and open struct, eg parsed XML)
  # I'll probably plug in libxml at some point
  class GDataStruct < OpenStruct
    def initialize filtered_data = nil, kind = :xmlsimple
      super self.hash_data filtered_data, kind
    end

    # override this as needed-- a bunch work like this
    def hash_data data, kind
      # the following is for xmlsimple output
      case kind
      when :xmlsimple
        h = { }
        data["property"].each { |el| h[el["name"]] = el["value"] }
        h
      end
    end
  end

  class User < GDataStruct
    def hash_data data, kind
      # the following is for xmlsimple output
      case kind
      when :xmlsimple
        data["login"][0]
      end
    end
  end

  class Group < GDataStruct
  end

  class GroupMember < GDataStruct
  end

  class Nickname < GDataStruct
  end

  class GDataStructArray < Array
    def initialize filtered_data = nil, kind = :xmlsimple
      super 0
      qlass = self.class.name.split("::")[-1].singularize
      list = (array_helper filtered_data, kind)
      ! list.nil? and list.each { |el| self.push ((GDataMarketplace.const_get qlass).new el, kind) }
    end

    def array_helper data, kind
      case kind
      when :xmlsimple
        list = data['entry']
      end
    end
  end

  class Users < GDataStructArray
  end

  class Groups < GDataStructArray
  end

  class GroupMembers < GDataStructArray
  end

  class Nicknames < GDataStructArray
  end

end
